Skip to content

feat(objectql)!: email template names held per namespace, a send resolves its template from its package, and ?package= reads an email template exactly (ADR-0150 child B, webhook family 1/3) - #32214

Merged
objectstack-fleet[bot] merged 46 commits into
mainfrom
claude/project-thread-3giljf
Nov 12, 2026

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Part of #31699
Fixes #32422
Supersedes #32089

维护者速读

Clause-②: yes (narrowing) — six refusals where an item was served or written before. (1) GET /meta/email_template/NAME?package=X (and getMetaItem with a packageId) for a name package X does not hold is refused 409 RESOURCE_CONFLICT (not-held-by-qualifier, naming every holder), where it served the first registered package's template; first-party callers that name a package: 0. (2) The bare GET /meta/email_template/NAME of a name two packages already held before the one-holder rule (a clash the cold boot seals with a warning) is refused 409 naming both, where it served the first registered one (ADR-0150, covered types). (3) A flow's notify node now sends its template as the flow's own package (SendTemplateInput.callerPackageId): from a package whose own flow body is armed, a template name no holder in that package's declared dependency closure holds (another package's template outside it, or a template only the environment authored) is refused 409 naming the holders, where the send served the one holder; first-party packaged flows that send a template: 0. (4) A read naming a package (?package=X, getMetaItem with a packageId, /meta/TYPE/NS/NAME) that reaches a package-less row (the environment's item, or a sys_metadata row bound to no package) of a name two or more packages hold is refused 409 RESOURCE_CONFLICT (ambiguous-in-closure, naming every holder), where each holder's address served that row as its own, and the list scoped to a holder (which a send made from it reads) leaves the row out; for every type read by package (PACKAGE_SCOPED_READ_TYPES: app, page, dashboard, report, book, analytics_cube, tool, skill, agent, api, email_template, picklist, mapping, dataset, permission); first-party: 0, the full dogfood battery green with it. Widened beside it: the engine's package door (SchemaRegistry.installPackage), its item seam (SchemaRegistry.registerItem) and the cold boot's holder seal (SchemaRegistry.sealBareNameHolders) accept, for an email template, a second package holder they refused before: one that declares a namespace other than the holder's. (5) A rollback by name of an email template in the database loader (DatabaseLoader.registerRollback) whose name has more than one row slot (two packages' rows, or a package's row beside the package-less row) is refused 409 RESOURCE_CONFLICT (ambiguous-in-environment, naming every holder) before any write, where it overwrote the first row the driver returned with a body that may be another holder's; with one row slot it restores the row of the member the restored body declares (its locale), and is refused as not found when that member has no row, where it overwrote whichever member's row came first. (6) A delete by name with no package of an email template two packages hold (DatabaseLoader.delete) is refused 409 RESOURCE_CONFLICT (ambiguous-in-environment, naming both), where it deleted the first row the driver returned; a delete naming a package takes that package's rows, every member, and only those, where it took the first row when that row was the package's; with one holder a delete by name takes every row of the name, where it took the first. First-party callers that roll back or delete an email template two packages hold: 0. The loaders' unscoped reads (loadMany, loadManyKeyed) keep every member of every slot, one row per (package, name, member), as before this change they answered every row.
Metadata-protocol: narrowed
Skills-docs: none — content/docs/kernel/runtime-services/email-service.mdx gains the callerPackageId member (how a send resolves its template name); no published skill teaches the scope of an email template name or the send's package; skills/objectstack-platform/SKILL.md's customization paragraph (a pair's added locale served on the base's address, a dependent's only under its own ?package=) stays true for every one-holder name and is pinned by the #28802 dogfood, which stays green.

Narrowing: yes — first-party callers: 0 (measured). For an email template, (4) changes the mail with no refusal: once a second package holds the name, the first holder's send moves from the operator's customized package-less wording to its own shipped wording, until the row is saved into one holder's pair. (1) No in-tree caller reads an email template with a package that does not hold it (Studio's bundle editor reads at the package its list names the item under). (2) No first-party package pair clashes on an email template name. (3) No first-party packaged flow's notify node names a template: or a literal payload.template: of the files under examples/ and packages/ (tests, dist and the engine itself excluded) that carry a type: 'notify' node, none carries a string template: key (the notify nodes of app-showcase and app-todo send inline title / message). (5) and (6) No first-party package pair holds an email template name two packages share, so no first-party rollback or delete by name meets the refusal. (4) No first-party package pair holds one name of a package-read type beside a package-less row of it; the full dogfood battery (which boots the showcase, CRM and every example app) stays green with the refusal on. The full dogfood battery ran on the final head (Verification).

This is the first of three PRs for #31699 (seat ruling Q1 = A): email_template here, then webhook, then connector (Fixes #31699). Only email_template joins NAMESPACE_SCOPED_BARE_NAME_TYPES here.

Patch round 8

This round answers the epic ruling 6809488921 and the contract review 6809461650 (VERDICT FAIL at 6ed3bea2).

Item Change Pin
R7-A (must-fix): keep the column bind, narrowly New DatabaseLoader.bindReadRowPackage(type, name, item), declared as an optional MetadataLoader member. MetadataManager.register calls it in place of the write-through it skips under skipSourceLoader. It is the only call the pass makes into the loader an item came from. It binds the empty package_id column of the legacy row that the loader's plural read handed item out from. The package is the one that row's own stored body names: it is recorded from the stored row at the read, and checked again against a fresh read of the name's rows before the write. The write is the column only: no body, no version, no history row, no insert. It never reads item's stamp as it stands now, so it never uses the host's applyProtection stamp. It never uses a loadMany keying: withEntryPackage stamps only a column-bound row, and such a row is never a candidate. It never binds a row the read did not hand out. It never binds into a slot whose column another row of the name already holds (the same member, for a bundle). A row whose stored body names no package is never a candidate. metadata database-loader.test.ts "[#31699 R5-A]", run through the real MetadataPlugin._loadFromFileSystem. (a) "[R7-A] an agent legacy row": on a host with a different packageId, the column is bound to the body's package by exactly one update('sys_metadata', 'legacy', { package_id }), and the row is otherwise equal (body, version 1), with no history row. (b) "[R7-A, R7-B] a package-less row on a host with a packageId": the row is unchanged, with no update. R5-A pins (1), (2) and CONTROL (4) are unchanged and green. Ablated A25.
R7-B (taken as must-fix): the host stamp is no longer saved back The changeset's boot bullet now declares the move. On a host that declares its package, a package-less DatabaseLoader row moves FROM saved back at boot with the host's stamp (_packageId, _provenance), a new version and a history row, and, for a type stored per (package, name), bound to the host, TO left as stored. Measured with the skip ablated: an agent row went to package_id = host, version 2, a stamped body and 1 history row; a view row went to version 2, a stamped body and 1 history row, with its column still empty. With the skip in place, both were unchanged. Pin (b).
R7-B: the read cache Copied, not only stated. DatabaseLoader.loadMany and loadManyKeyed hand out a shallow copy of each cached body. The pass's applyProtection therefore stamps the copy, and the plural-read cache keeps the body as stored. applyProtection writes top-level keys only (_packageId, _packageVersion, _provenance, _lock, and the like), so a shallow copy is enough. loadSlots and loadManySlots answer the cached rows as before; the pass does not read them. Pin (b) also asserts that the same loader's loadMany('agent'), read after the pass, answers the body unstamped. Ablated A26.
R7-C: the stale stored body after a file edit Declared, not fixed (see Acceptance notes). It is not a few lines inside loadMany's keying: loadMany never sees the host package, because the pass stamps it after the read. Measured through the real pass with a scratch test, not kept.
N9: a registry assertion in pins (1) and (2) Not added, because the measurement contradicts it. MetadataManager's in-memory registry keys an email_template by (package, name) with no member, and a write naming no package lands in the one holder's slot (storeWriteKey, D4). After the pass, pin (1)'s registry holds one entry: the alpha slot, holding the package-less env older body. Pin (2)'s registry holds the alpha slot with the package-less fr-FR body. Two members of one package collapse the same way. The rows themselves stay pinned as unchanged. —
N10 MetadataRegisterOptions is exported beside MetadataUnregisterOptions from the package index. typecheck
File surface Beyond the take-over claim's four files, this round touches three more. database-loader.ts holds the column-only path and the hand-out copy; ruling 6809488921 places both in the source loader. loader-interface.ts holds the optional bindReadRowPackage member, so the manager calls it typed. index.ts holds the N10 export. —

Patch round 7

This round answers the epic ruling 6801410166 on R5-A, which the red-line re-read 6801341801 and the contract review 6801348644 (VERDICT FAIL at cfb8f300) raised. Its first commit, 0f93f8f1, was pushed by the previous dev with no report and no verification. This round checks it against the ruling, adds what was missing, and measures it.

Item Change Pin
R5-A (must-fix): the boot priming pass persisted rows back into the loader it read them from MetadataManager.loadMany records which loader each item object came from, in a WeakMap. register(type, name, item, { skipSourceLoader: true }) skips the write-through to that one loader, and every other datasource: loader still receives the item. MetadataPlugin._loadFromFileSystem passes the option. With the skip ablated (A23; the write-through loop is then byte-identical to cfb8f300's), a package's en-US row beside a package-less en-US row took the package-less body. Beside a package-less fr-FR row, a package-bound copy of it was inserted. metadata database-loader.test.ts "[#31699 R5-A]", run through the real MetadataPlugin._loadFromFileSystem over a DatabaseLoader. (1) Both rows are unchanged, with no update and no insert. (2) The member-absent neighbour inserts nothing. (4) CONTROL: a file-system item still seeds the database loader. This uses the plugin's own FilesystemLoader over a temporary rootDir, with the host's packageId stamped. Ablated A23.
The seeding path, before and after The writes are the same both ways: one sys_metadata row bound to the host's package, its one history row, and no update. That holds with the skip ablated (before) and in place (after). CONTROL (4) pins the exact writes.
Round 6's member-aware save Unchanged, and #32422 stays closed by it. Round 6's pins are kept as they are. Their prime helper registers loadMany's items back without the skip, so they now pin a register made by any route other than the boot pass, and its doc says so. Round 6's pins (pin 3).
onlyBoundStampAdded Kept: it is not dead. A24 ablated it with the skip in place. Of the whole metadata suite, exactly two pins went red: round 6's two that register loadMany's column-stamped bodies back without the skip (the column-bound two-member slot, and R4-B). The skip covers the boot pass only. The stamp loadMany adds reaches every caller. Without the check, any other register of such a body, or the pass's write into a second datasource: loader, would write a new version that differs from its row only by the stamp. database-loader.ts is not touched this round: the claim's file surface does not name it, and the reason is recorded here and in the report. A24.
Doc 0f93f8f1 put MetadataRegisterOptions between MetadataUnregisterOptions' doc comment and its declaration, which left that comment on the wrong type. It now sits above it. —
Gate check:test-source-alias was red on 0f93f8f1. Its R5-A pins import MetadataPlugin, whose source loads @objectstack/metadata-fs lazily, so the load fell inside a case's clocked window. database-loader.test.ts now imports it at module top, as the gate prescribes. check:test-source-alias ✓
Declaration The six refusals are unchanged. The save bullet's FROM → TO is unchanged, because the save did not move this round. The changeset carries the ruling's note as its own line: "A boot no longer writes back rows it read from the same loader". Check Changeset
Merge Merged origin/main at 14754c70 (3 commits: #32415, #32454, #32453). The merge commit has no conflict and touches none of this PR's files. Tests

Patch round 6

This round answers the contract review 6799021376 on f9f589da (VERDICT FAIL: must-fix R4-A, should-fix R4-B, nit N8) under the epic ruling 6799056319, which pulls #32422's fix into this PR.

Item Change Pin
R4-A (must-fix) — the boot priming pass overwrote a stored locale Measured first, on f9f589da, through MetadataManager over a DatabaseLoader. The priming pass that MetadataPlugin._loadFromFileSystem runs (loadMany, then register each) over a slot holding en-US and fr-FR replaced the en-US row's body with fr-FR's: version 3, plus a history row. This happened whether the bodies were stamped or bound by the column only. A direct save of fr-FR into a slot holding only en-US overwrote the en-US row. The fix: DatabaseLoader.save and slottedSaveTarget now write, for a bundled type stored per (package, name), the row of the member the body declares. The canonical member is null, as storedBundleMemberOf reads it. A member with no row in the slot is inserted with its bundle_member, which matches the store's unique key, so a save never writes over another member's row. The slot is still chosen from every row of the name, and every unbundled slotted type keeps one row per slot. MemoryLoader was checked the same way: a slot there holds one item, its save of a second locale replaces the first as on main, and no new path reaches it, because register persists only to datasource: loaders. It is unchanged; see Acceptance notes. metadata database-loader.test.ts "[#31699 R4-A] the save of a bundled type writes its own member's row": the priming pass leaves a two-member slot's rows unchanged, with stamped bodies and with column-bound ones, with no update and no insert; a direct fr-FR save inserts its own row, leaves en-US untouched, and a second fr-FR save updates its own row; CONTROL an unbundled slotted type (agent) saves to its one row. Ablated A22.
R4-B (should-fix) — MetadataManager.loadMany merged two packages' same member when the package was in the column only Measured on f9f589da: over two packages' rows bound by package_id alone, loadMany answered alpha's two members only, and the priming pass inserted a package-less row. The fix: MetadataManager.loadMany reads a bundled slotted type through the loader's keyed entries where the loader has them, so each entry keeps its row's package. It hands such a body on stamped with that package (_packageId, as the protocol's own read binds a row), so a priming pass registers it into that package's slot and its member's row. A save whose body differs from its row only by that stamp naming the row's own package writes nothing (onlyBoundStampAdded). Same describe, "R4-B": over rows bound by the column only, loadMany answers all four (package, member) pairs, each stamped with its package, and the priming pass writes nothing (no update, no insert).
N8 and the declaration The changeset states that MetadataManager.loadMany and loadManyKeyed answer one entry per (package, name, member) for an email template, and adds a save bullet FROM → TO beside the slotted-store bullet. That bullet is not a refusal. The sentence that the save "still writes into its slot's first row" is removed. The six refusals are unchanged. Fixes #32422 sits under Part of #31699. Check Changeset
Merge Merged origin/main at 74216b60 (3 commits): a merge commit with no conflict, touching none of this PR's files. Tests

Patch round 5

This round answers the epic ruling 6796068023 on round 4's R3-B reading: option A, fixed in this PR. Its answer 2 confirms Clause-②: yes (narrowing).

Item Change Pin
R3-B, the fix — a bundled type's unscoped loader reads dropped bundle members DatabaseLoader.loadMany / loadManyKeyed keep one row per (package, name, member) for a bundled type stored per (package, name), with the canonical member spelled null as the store's bundle_member column spells it (storedBundleMemberOf: the column when it names a member, else the body's locale). Every member of every slot is kept. Every other slotted type keeps one row per name, as #32246 has it. (Round 6: the save left out here as #32422 is member-aware too, and MetadataManager.loadMany keeps a column-bound package.) MemoryLoader answers every slot of a bundled type alike. MetadataManager.loadMany / loadManyKeyed, which merged the loaders' items one per name, merge a bundled type per (package, name, member) (bundleEntryKey), and DatabaseLoader's keyed entries of such a type carry the row's package. A rollback by name of a name with one row slot restores the row of the member the restored body declares, and is refused as not found when that member has no row. The rollback and delete refusals are unchanged from round 4's measurement. The save path's locale-blind overwrite was left out here as #32422 and is fixed in round 6. metadata database-loader.test.ts "[#31699 R3-B] a bundled type keeps every member of every slot": one two-locale bundle keeps both locales (460df4a: only fr-FR); two packages' bundles keep all four rows (460df4a: 1 of 4); CONTROL a member's older row is no second member; a rollback by name with two holders is 409 naming both, with no write; a one-slot rollback restores its member's row; a delete by name with two holders is 409, and a delete naming a package takes that package's rows only; a save fills package_id; MemoryLoader answers both packages' templates. metadata-manager-match-endpoint.test.ts: the #32246 one-item-per-name pins now run over the unbundled slotted types, and a bundled pin covers the manager's loadMany / loadManyKeyed keeping each package's member while load still answers the one saved last. Ablated A21.
Declaration The changeset's Clause-②: paragraph is recounted to six refusals: (5) a rollback by name with more than one row slot, (6) a delete by name of a name two packages hold, each FROM → TO. The BREAKING paragraph covers both, and the slotted-store bullet states that the loaders' unscoped reads keep every member of every slot. The same Clause-②: line is here. check-adr-0087-registration, Check Changeset
Merge Merged origin/main at 7d82b2da (5 commits): a merge commit with no conflict, touching none of this PR's files. Tests

Patch round 4

This round answers the contract review 6792103054 on 7136e339 (VERDICT PASS; should-fix R3-A and R3-B; nits N6 and N7) under the epic ruling 6792134290, which withdraws correction 6788403217 and lets answer 2 stand as first given.

Item Change Pin
Merge Merged origin/main at cbf40f85 (9 commits, #32361 through #32404), a merge commit with no conflict. Of this PR's files it moves protocol-exact-qualifier.ts, package-scoped-resolution.ts and its test, package-slotted-store.ts, registry.ts and engine.ts; both sides are kept. Tests
R3-A (should-fix) — the R2-B pin read a row the store never holds The fixture envRow writes the canonical member's package-less row as the store writes it, bundle_member: null (overlayMemberValue, migrations/overlay-index.ts); any other member keeps its value. Measured on the merged tree before any assertion changed: ?package=A for en-US, a member A ships, beside that row answers 409 RESOURCE_CONFLICT ambiguous-in-closure naming A and B, which is the ruled answer. The other nine cases of the describe stay green on the corrected fixture. No source change. objectql "[#31699] a package-less row…" "(R3-A)": for A and for B, the exact read refuses 409 naming both; the holder's list and the send's read serve the holder's own shipped member, and the row is withheld. Ablated A20: 8 red, the R3-A pin among them.
R3-B (should-fix) — the loader half of PACKAGE_SLOTTED_STORE_TYPES Fixed in round 5. Round 4 measured, and stopped as the claim directed, that with email_template in the set DatabaseLoader.loadMany and loadManyKeyed answered one row per name: one package's en-US + fr-FR bundle answered only fr-FR, and two packages' bundles answered 1 row of 4, where BASE (the type out of the set, same tree) answered every row. The rollback and delete refusals measured then are kept, and the save's locale-blind overwrite (on main too) is #32422. Round 5.
N6, N7 Not touched: they ride #32407. —

Patch round 3

This round answers the delta contract review 6785448728 on d8084865 (VERDICT PASS, two should-fix and three nits) under the claim amendment 6788098985 and the epic answers 6788092691.

Item Change Pin
Merge Merged origin/main (the PR was dirty): permission (#32162) and email_template sit side by side in PACKAGE_SCOPED_READ_TYPES, PACKAGE_SLOTTED_STORE_TYPES and objectql's WIRED_SINCE; #32162's permission describe and this PR's describes are both kept. Narrowing (4)'s type list now names permission too, since the package-less refusal covers every type in PACKAGE_SCOPED_READ_TYPES. objectql package-scoped-e8-readers.test.ts, package-scoped-exact-qualifier.test.ts
R2-A (should-fix) — a row hydrated into the registry, stamped '' or the provenance sentinel sys_metadata, skipped the package-less owner check exactQualifierItem treats '' and sys_metadata as no package before the owner check, so such a row of a name two packages hold is refused 409 ambiguous-in-closure at both exact addresses. objectql "[#31699] a package-less row…": a row registered into the registry stamped sys_metadata, and one stamped '', under two holders → 409 at both naming both; CONTROL one holder → served. Ablated A19 (2 red).
R2-B (should-fix) — what a holder's send does with a withheld package-less row Epic answer 1: withholding is accepted. The changeset's BREAKING paragraph (and the narrowing paragraph here) now states the mail consequence: once a second package holds the name, the first holder's send moves from the operator's customized package-less wording to its own shipped wording, with no refusal, until the row is saved into one holder's pair. Epic answer 2 stands as first given: 6792134290 withdraws correction 6788403217. ?package=A for a member A ships, beside a package-less row of a name two packages hold, answers 409 RESOURCE_CONFLICT ambiguous-in-closure naming both, while A's list and a send from A serve A's own. Corrected in round 4: this round's measurement used a fixture row the store never holds (the canonical member as bundle_member: 'en-US', where the store writes NULL), so the read missed the row and answered with the registry's item. Round 4's R3-A pin replaces this round's pin, which asserted the registry's answer to a row the read never found.
N2r Changeset: the slotted-set sentence lists api, agent, skill, action, email_template, permission. Body: "What changes" gives the namespace set as agent, skill, action, email_template. —
N4 Changeset: the notify bullet says a template named in the literal payload.template names the armed package too. —
N5 Changeset: the send bullet no longer lists the environment overlay among what a two-holder name's address reads; a package-less overlay of such a name is left out. —

Patch round 2

This round answers the contract review record 6772203835 on 4c176c5b (VERDICT FAIL), the epic seat's remaining red-line items (the package-less row; the App pair's registry slot), and the delta review of #32277 (6774012987: M1r, S2), whose owner-check order this branch shares, with the server seat's rulings on F-C and N1, in one push.

Item Change Pin
Epic: the legacy package-less row under two holders (replaces round 1's "declared, not changed") exactQualifierItem asks packageLessRowOwners (exported from metadata-protocol) before keeping a row that carries no package: when two or more packages hold the name, the read is refused 409 ambiguous-in-closure naming every holder. The package-scoped list leaves such a row out (packageLessRowWithheld in the stand-in rows), so a send made from either holder renders that holder's own. It is generic: every type in PACKAGE_SCOPED_READ_TYPES; a platform-requested name (auth.*) and the _env address are untouched. os migrate stored-references lists each such sys_metadata row under packageLessRows with its candidates. Declared as narrowing (4) for every such type, agent, skill and the E8 types included. Census: first-party 0; the full battery green. objectql package-scoped-exact-qualifier.test.ts "[#31699] a package-less row of a name two packages hold": email_template two holders → 409 on both, each list and the send's read serve the holder's own; agent, skill, app, dashboard: env item + two holders → 409, + one holder → served; CONTROL _env unchanged. metadata-protocol protocol-exact-qualifier.test.ts: packageLessRowOwners names every holder, CONTROL auth.* and a type not read by package → none. CLI stored-package-references.literal-namespace.test.ts: THE PIN lists an agent and an email_template row with both candidates; CONTROL one holder, or a bound row → not listed. Ablated A10–A13.
F-A (must-fix) — a literal payload.template resolved environment-wide The notify node records the armed package (templateCallerPackageId) when the literal payload carries a non-blank template string, as the channels read it, and the inline path forwards it; a literal templateCallerPackageId key is still dropped and replaced. notify-node.test.ts "a template reference written in the literal payload … names the armed package too". Ablated A14.
F-B (= #32277 delta M1r) — a bound pair row was still served at the App's own address The owner check is now one self-contained function, pairRowOnAddress (exported), which exactQualifierItem and pairMemberWithheldAt both ask, so a generic pair line (#32277) can hoist it to the top of the function. It reads the declared base (overrides.package) first, before the App-pair keep, in both the exact read and the list. objectql "[#31699 F2]" "a bound row is served on its base only, even when the App itself holds the name beside it": served at X, refused and unlisted at the App. metadata-protocol "[#31699] pairRowOnAddress": declared base read first. Ablated A15 (exact read), A16 (list), A15b (unit) and A15c (one mutation in the shared function, dist).
S2 (#32277 delta review) — a sole owner that is not among the address's bases was still served pairRowOnAddress answers not-member when the row's one owner is not one of the address's bases, so the read falls through as for another package's item, and the list leaves it out. Through the doors today the qualifier refuses a non-holder first (not-held-by-qualifier), so the guard is pinned on the function. metadata-protocol "[#31699] pairRowOnAddress": a sole owner elsewhere → not-member; CONTROL the owner's own address → serve. objectql "[#31699 F2]": a package sharing the pair but holding no template of the name does not serve the member (refused 409) and does not list it. Ablated A17.
App pair's registry slot (epic ruling via seat 4) — SchemaRegistry.getPackageScopedItem(type, name, APP) returned the App pair's plain-slot row whatever it declared getPackageScopedItem skips a paired <base>.custom row that declares another holder as its base (pairRowDeclaresAnotherHolder), so the App's address answers the App's own item. Generic for every type. objectql package-scoped-resolver.test.ts "[#31699] a pair row that declares another holder as its base is skipped on the base's address": email_template and page answer the base's own item, the other holder's unchanged; CONTROL a pair row declaring its own base, or none, is still the base's. Ablated A18.
F-C — the remedy "declare overrides.package" has no door Seat ruling: the read keeps honouring a declaration (fail-closed), and the report hints, the module doc and the changeset now say the truth: the save gate refuses the declaration on a member the base does not ship (declared_package_ships_no_such_item, #28777), so no door binds an added member to one base today and the row stays refused and listed until it is deleted. For a package-less row, where a holder ships the item, the remedy is saving the customization into that holder's pair declaring it and deleting the row. The bind door is raised with the epic seat as an open item. CLI report text.
F-D — the changeset's legacy-row paragraph Superseded by the epic fix: the paragraph now states the refusal. —
N1 Metadata-protocol: narrowed (a template value refused at delivery; the level stays minor). check-adr-0087-registration ✓
N2 The changeset's slotted-set sentence lists api, agent, skill, email_template; narrowing (4) lists PACKAGE_SCOPED_READ_TYPES in full. —
N3 email-service.mdx documents callerPackageId; the Skills-docs line names the page. pnpm lint ✓
③ Merged origin/main at fb71296c: PACKAGE_SCOPED_READ_TYPES keeps mapping and dataset beside email_template, and objectql's WIRED_SINCE carries api, email_template, picklist, mapping, dataset. Then at e31ea52a (#32213, #32239, #32274), and at 326453eb (#32310's caller arm: CallerPackageAddress exported beside this PR's owner helpers; bareNameHoldersMeet keeps the platform-requested auth.* exception ahead of spec's namespacedHoldersMeet; #32310's dataset describe and this PR's describes both kept): action and email_template both narrowed (NAMESPACE_SCOPED_BARE_NAME_TYPES = agent, skill, action, email_template; PACKAGE_SLOTTED_STORE_TYPES = api, agent, skill, action, email_template); flowCallerPackage takes main's slot-key body (#31696) and stays public for the notify node. Full battery and CI on the final head. Tests
Second lander after #32277 (9bff554e); the doubles One owner check: #32277's local pair line in exactQualifierItem now asks this PR's exported pairRowOnAddress (main's local pairMemberOwners, declaredOverrideBase and isAppPairOf copies dropped for the exported ones); the generic pair line covers every package-scoped type, as #32277 has it. The decision table is #32277's corrected order case by case: the App shortcut only when nothing or the App is declared; a sole owner served only when among the address's bases; no owner kept unless the row declares a base the address does not name. email_template joins NAMESPACE_SCOPED_BARE_NAME_TYPES where #32277 moved it (@objectstack/core). The package-less refusal stays on the types whose by-name readers consult (package, name) (PACKAGE_SCOPED_READ_TYPES): the other package-scoped types #32277 newly judges at ?package= are out of scope (below). metadata-protocol "the exact qualifier keeps a pair's item on its base's address": the pair's item is the base's own for every type (the CONTROLs that pinned the old bundled-only gate now pin #32277's generic line). objectql #32277's "[#32076]" pair-row describe stays green; its premise now reads readsByPackage('email_template') === true. Four getItem-reading doubles that bare-read an email template (metadata-protocol protocol.reads-environment-only, rest rest-server-meta-read-org-scope and rest-server-meta-history-diff-org-scope, runtime meta-write-org-scope) now say they read it by package, as SchemaRegistry does; without that #32277's narrowed arm refuses them 501.

Patch round 1

This round answers the contract review record 6761777785 (VERDICT FAIL) under the epic seat's rulings 6761802819, from claude/project-thread-x11fa6@a52f89e0 merged with origin/main.

Item Change Pin
F1 (must-fix, epic: root fix) — the send resolved caller-less, so every send of a two-holder name would have been refused SendTemplateInput / RenderTemplateInput.callerPackageId (spec); EmailService.resolveAndRenderTemplate hands it to every TemplateLoader.load rung and bundleLocales; the registry loader resolves D3 from it (bundleAddressOf) and, for a name two packages hold, reads only the resolved holder's address (getMetaItemsForExecution({ type, packageId })). The notify node records AutomationEngine.flowCallerPackage(context) (the flow's armed package, never the definition's _packageId stamp) as templateCallerPackageId; the email and inbox channels forward it. A literal payload key of that name is dropped. plugin-email template-loader.test.ts "[#31699 F1]": own holder per namespace (alpha, beta), closure holder, outside-closure 409, no package: two holders → 409 (undefined and null), one holder → served, environment item → served, dependent addition reads its base, registry-only narrowing. send-template.test.ts: the caller reaches every rung (control: omitted / null / empty → none). notify-node.test.ts: armed package named, env body stamped _packageId names none, literal key dropped, inline path names none. email-channel.test.ts, inbox-channel.test.ts: forwarded (control: absent). Dogfood bare-name-one-holder-flow-family: renderTemplate as alpha → alpha's subject, as beta → beta's.
F2 (should-fix, epic: refuse both when two pairs could own the row) exactQualifierItem keeps a pair row on a base only when the row has one owner among the name's holders (pairMemberOwners), else 409 ambiguous-in-closure naming every owner; the App's own address keeps ruling A; a row declaring overrides.package is served at that base only (round 2: read first, F-B). The package-scoped list (#28802 block) leaves such a row out (pairMemberWithheldAt). os migrate stored-references lists it under pairMembers with its candidates. objectql package-scoped-exact-qualifier.test.ts "[#31699 F2]": two holders → 409 on both exact reads naming both, both lists omit it; one holder → served (#28390 A); the App's own address → served; after a bind (overrides.package: X) → served at X only. CLI stored-package-references.literal-namespace.test.ts: listed with candidates suitex/welcome, suitey/welcome (control: one holder or a declared row → not listed).
Legacy package-less row under two holders (epic, round 1: declare) Refused in round 2 (above). —
N1 — Clause-② did not name the pre-phase-1 bare 409 Clause-② line (2) names it, in the changeset and here. check-adr-0087-registration ✓
N2 — bare-name-one-holder.ts said the send "reads the resolved holder's bundle only" Now true in code (F1 reads the resolved holder's address), and the sentence says what the loader does. the F1 narrowing pins
N3 — the dev report's dogfood_battery line contradicted the ! and the arm The full battery ran (on 1484ab59); the report states its numbers. Tests

B-shape additions (#31527 comment 6766935846, applied at the seat's call):

Item Answer for email_template Pin
1. Slotted store before narrowing PACKAGE_SLOTTED_STORE_TYPES (@objectstack/metadata) is now api, agent, skill, email_template (main's #32151 entries kept), in the same change as the NAMESPACE_SCOPED_BARE_NAME_TYPES entry: two packages' same-named templates both stay in MetadataManager's in-memory store, a read naming a package answers that package's own (never another's), and a read naming none of the two-holder name is the D3 409 naming both. metadata metadata-manager-email-template-slot.test.ts: both kept, by-package read (both faces), bare read 409 ambiguous-in-environment naming both; CONTROL one holder answers the one item; CONTROL view (not slotted) keeps one item per name. Main's superset pin (objectql registry-bare-name-one-holder-ai-family.test.ts, #32151) covers it too. Ablated (A9).
2. Removal by slot Not built here: it rides #32198's generic mechanism. (The slotted store's own unregisterPackage already drops only that package's slot, as for api.) —
4. readCodeLayerForCarryForward No redactor: email_template registers none, so a save never takes the carry-forward's code-layer read for it, with or without a package; there is no unhandled throw to reach. metadata-protocol protocol.metadata-redaction.test.ts: hasMetadataRedactor('email_template') and ('email_templates') are false.
5. Holder set over the served list (#32183) Not yet: the email template doors (the bare read, ?package=, the send's D3 resolution) read the registry's holder set (packageScopedHoldersOf), so a disabled package still counts as a holder and is named in a refusal while the served lists withhold its items; the install gate counts it too, as it must. This is the class #32183 tracks for every package-scoped type and is fixed there once; it is declared in the changeset. Neither F2 nor the send path covers it. —

Round 1 declared the legacy package-less row under two holders as unchanged; round 2 refuses it (above).

What changes

  • The switch. NAMESPACE_SCOPED_BARE_NAME_TYPES (@objectstack/core) gains 'email_template' (now agent, skill, action, email_template); PACKAGE_SCOPED_READ_TYPES gains it after 'api'.
  • Platform-requested names. Core's PLATFORM_REQUESTED_BARE_NAMES_BY_TYPE gains the eight auth.* mails; they resolve environment-wide whatever the caller and keep one overriding package per deployment.
  • Bundle addition. A package that ships only locales its base does not ship is still judged against the base whatever the namespaces (isBundleAdditionPair); a send made from it resolves from its closure.
  • The send (F1). callerPackageId on the send contract, through EmailService, TemplateLoader (TemplateLoadOptions, exported) and the registry loader, which narrows to the resolved holder's address only when two or more packages hold the name, so every one-holder name reads byte-identically to before. The notify node, templateCallerPackageOf in service-messaging, and both channels.
  • Exact qualifier (F2, round 2; merged with fix(metadata-protocol)!: ?package=, the qualified path and _env name the exact holder for every package-scoped type, and a by-name read the registry cannot judge fails closed (ADR-0150 D3/D5) #32277). pairMemberOwners, and the one owner check pairRowOnAddress (declared base first, the App's own pair, two owners refused, a sole owner elsewhere not served) that the exact read and pairMemberWithheldAt both ask, in metadata-protocol; packageLessRowOwners / packageLessRowWithheld refuse and withhold a package-less row of a name two packages hold, for every type read by package; both owner helpers exported for the CLI.
  • Registry. SchemaRegistry.getPackageScopedItem skips a pair row that declares another holder as its base.
  • Loaders (R3-B, round 5). DatabaseLoader and MemoryLoader keep a bundled type's unscoped reads one entry per (package, name, member) (isBundledStoreType, storedBundleMemberOf, bundleEntryKey in package-slotted-store.ts); MetadataManager.loadMany / loadManyKeyed answer one entry per (package, name, member) for it, loadMany reading a row's package from its package_id column when the body carries no stamp; DatabaseLoader.save writes the row of the member the body declares and inserts a member with no row (round 6, metadata: DatabaseLoader is locale-blind for a bundled type, so saving one locale of an email template overwrites another locale's row in the same (package, name) slot #32422); a one-slot rollback by name restores its member's row; a rollback by name with more than one row slot and a delete by name of a two-holder name are refused 409.
  • Notify (F-A). A literal payload.template names the armed package as template: does.
  • Docs. email-service.mdx: callerPackageId.
  • Upgrade report. collectStoredPackageReferences adds pairMembers (a keyset walk of active sys_metadata rows of package-read types stored under a customization package with two or more owners); os migrate stored-references and packageLessRows (package-less active rows of package-read types whose name two or more packages hold); os migrate stored-references prints both, with hints that say no door binds an added member to one base today, and counts them in its total.
  • Unchanged. The refusal's envelope, code and sentence; every type outside the set; the webhook and connector readers (PRs 2 and 3).

Tests

All on this branch merged with origin/main at 14754c70, after pnpm install --frozen-lockfile and a build of the dogfood closure (--filter '@objectstack/dogfood^...') on 17cfbf8b, the final head.

  • Package suites, each with its typecheck at exit 0: metadata 1334/1334, objectql 12319/12319, metadata-protocol 22703 passed (29 skipped) and plugin-email 911/911.
  • node scripts/pm/dispatch-gates.mjs --commands on 17cfbf8b: all 127 commands run, each exit recorded before any pipe. --ran gives 127 derived, 127 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. No scratch test or ablation ran during the sweep.
  • pnpm lint on 17cfbf8b: exit 0.
  • CI on 17cfbf8b: 68 check-runs, 58 success, 10 skipped, 0 failed.
  • Full dogfood battery on 17cfbf8b: pnpm --filter @objectstack/dogfood test under scripts/pm/os-verify-lock.sh gives VERDICT command-exit 0, with 451 files passed (3 skipped) and 3780 tests passed (42 skipped).

Acceptance notes

  • Out of scope, named for the seat to card:

    • The package-less refusal for the package-scoped types outside PACKAGE_SCOPED_READ_TYPES that fix(metadata-protocol)!: ?package=, the qualified path and _env name the exact holder for every package-scoped type, and a by-name read the registry cannot judge fails closed (ADR-0150 D3/D5) #32277 newly judges at ?package= (flow, action, webhook, connector and the rest of the covered set). Extending it there would refuse a request main accepts, so it needs its own narrowing entry, census and pins.
    • MemoryLoader (the artifact door's store) keeps one item per (package, name) slot, so a package body's second locale of one template replaces its first there, as on main (where the store was keyed by name). Nothing new reaches it: register persists only to datasource: loaders, so the priming pass never saves into it. Checked for R4-A and unchanged.
    • For an unbundled slotted type (agent, skill, action, api, permission), a DatabaseLoader row bound to its package by the package_id column alone reaches register unstamped. With two holders, a register of it writes a new package-less row. This holds on main by the same mechanism (metadata: the boot priming pass hands a column-bound slotted row to register unstamped, so with two holders of a name the DatabaseLoader save inserts a package-less row (unbundled slotted types) #32458). Round 7's source skip is per loader and covers every type, so the boot pass no longer does this. Measured with two packages' column-bound agent rows: with the skip ablated, the pass inserted a package-less copy; with it in place, nothing. Any other route that registers such a row still does it, which stays metadata: the boot priming pass hands a column-bound slotted row to register unstamped, so with two holders of a name the DatabaseLoader save inserts a package-less row (unbundled slotted types) #32458's.
    • Round 7's source skip stopped the boot pass from binding a legacy row's empty package_id column. Round 8 (R7-A) gives that bind back, narrowly. A legacy row is a row of a type stored per (package, name) whose body carries the stamp. The pass binds the column of the row the loader handed out, to the package that row's own stored body names, and writes nothing else. This is the same column write as fix(metadata)!: the artifact door's in-memory loader and DatabaseLoader store api, agent, skill and action per package #32347's column-only update in DatabaseLoader.save, now reached without the save. A row whose stored body names no package stays package-less. A row whose slot another row of the name already holds by its column (the same member, for a bundle) is left as it is.
    • Known move (R7-C), declared, not fixed. After a file edit, the first boot registers the stale stored body over the fresh file: FROM the file's body registered, TO the stored row's previous body registered. That same boot saves the file's body into the database loader, so the boot after it serves the file. The cause is round 5's keying. The file item keys package-less and its seeded row keys under the host package, so loadMany answers both, and the later registration wins the one (package, name) entry. Measured through the real pass with the file edited from v1 to v2: the next boot's registry held v1 while the row held v2 (version 2), and the boot after held v2. The card is filed by the seat.
    • Measured, not changed (N9). For an email_template, MetadataManager's in-memory registry holds one entry per (package, name), with no member. A write naming no package lands in the one holder's slot (D4). So after the pass, a package's row and a package-less row of one name leave one registry entry: the package's slot, holding whichever body was registered last. Two members of one package leave one entry the same way. The stored rows are unchanged (pins (1) and (2)).
    • SchemaRegistry.overlaySlotBinding still binds a pair-stamped bare-slot row to the base without reading overrides.package. This PR fixes the by-package read (getPackageScopedItem, pinned on the real registry). The binding's other readers are bundleMembersHeldBy (bundle-addition judgement) and getEnvironmentScopedItem, and they are untouched.
  • Ablations, each through scripts/ablation-replace.mjs (mutation landed, restored to the HEAD blob with an empty git diff HEAD); the metadata-protocol legs over a rebuilt dist with ablation-dist-preflight finding the marker, then --absent after the restore build:

    • A1 loader narrowing off (holders.length < 2 || true): 3 red (each namespace's send reads its own address, a dependent's addition reads its base's address, registry-only narrowing).
    • A4 EmailService drops the caller (loadOptions → none): 1 red (the caller reaches every rung).
    • A2 notify records no caller: 1 red (armed package named).
    • A8 the literal payload key is kept: 1 red (literal key dropped).
    • A3 email channel does not forward: 1 red. A3b inbox channel does not forward: 1 red.
    • A7 CLI lists only at 99 owners: 1 red (the two-holder row listed).
    • A5 exact qualifier refuses only at 99 owners (dist): 1 red (two holders → 409 on both).
    • A6 the list keeps the row (dist): 2 red (two holders' lists omit it; after a bind only X serves).
    • A9 email_template out of PACKAGE_SLOTTED_STORE_TYPES (on 4c176c5b): metadata 4 red (in the set, both kept, by-package read, bare-read 409), the two CONTROLs green; over the rebuilt metadata dist, objectql's superset pin 1 red (email_template: two packages' same-named items are both kept…).
    • A10 packageLessRowOwners answers none in the exact qualifier (dist; the marker comment is stripped by the build, so the preflight read ABSENT, and the red proves the mutation reached the built code): 5 red (email_template two holders; agent, skill, app, dashboard).
    • A11 the list keeps a withheld package-less row (dist, marker present): 1 red (each list serves the holder's own).
    • A12 the CLI report asks no owners: 1 red (THE PIN).
    • A13 the platform-requested exemption dropped: 1 red (CONTROL auth.* → none).
    • A14 the notify node ignores a literal payload.template: 1 red (F-A pin).
    • A15 the App-pair keep restored ahead of a declared base in the exact read (dist, marker stripped as A10): 1 red (F-B pin).
    • A16 the same in the list (pairMemberWithheldAt, dist): 1 red (F-B pin).
    • After the owner check moved into pairRowOnAddress: A15b the App-pair keep ahead of a declared base (source, unit): 1 red (declared base read first); A15c the same mutation over the rebuilt dist (marker present, then --absent): 1 red (the F-B pin in objectql, which reads the exact read and the list).
    • A17 the sole-owner-elsewhere guard off (source, unit): 1 red (S2).
    • A18 getPackageScopedItem keeps a pair row declaring another holder (source): 1 red (the registry pin).
    • A19 (round 3) '' and sys_metadata no longer count as no package (dist; the marker comment is stripped by the build, the red proves the mutation reached it): 2 red (the hydrated-row pins).
    • A20 (round 4) the package-less refusal fires only at 99 owners (owners.length > 99, metadata-protocol dist; ablation-dist-preflight found the marker in 2 built files, then --absent after the restore build): 8 red in "[objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699] a package-less row…", the R3-A pin among them; restored, 10/10 green.
    • A21 (round 5) the member dropped from the bundled unscoped key (unscopedRowKey(row.name, row.packageId, null), metadata source; the suite reads src/): 5 red in "[objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699 R3-B]" (one bundle, two bundles, the member CONTROL, the one-slot rollback, the delete); restored to the HEAD blob with an empty git diff HEAD, 8/8 green.
    • A22 (round 6) the member dropped from the save target (&& true in place of the member match in slottedSaveTarget, metadata source; a first attempt replaced it with );, which ablation-replace refused as unmeasurable, so it never ran): 4 red in "[objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699 R4-A]" (both priming pins, the R4-B priming pin, the direct fr-FR save), the unbundled CONTROL green; restored to the HEAD blob with an empty git diff HEAD.
    • A23 (round 7) the source skip off (false && ahead of options?.skipSourceLoader in MetadataManager.register, metadata source): 2 red in "[objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699 R5-A]". In (1) the package's en-US row took the package-less body. In (2) a package-bound fr-FR copy was inserted. The file-system CONTROL stayed green with the same writes, and round 6's pins stayed green. Restored to the HEAD blob with an empty git diff HEAD.
    • A24 (round 7) onlyBoundStampAdded off (false && ahead of it in DatabaseLoader.save), with the skip in place, over the whole metadata suite: 2 red of 1332. These are round 6's column-bound two-member slot and R4-B, the two that register loadMany's bodies back without the skip. Restored to the HEAD blob with an empty git diff HEAD.
    • A25 (round 8) the column bind off (await loader.bindReadRowPackage?.(type, name, data); → void 0; in MetadataManager.register): 1 red in "[objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699 R5-A]", the R7-A agent legacy row (its column stayed empty). R5-A pins (1) and (2), CONTROL, and the package-less pin stayed green. Restored to the HEAD blob with an empty git diff HEAD.
    • A26 (round 8) the hand-out copy off (const copy = entry.data; in DatabaseLoader.handOut), over the whole loader test file: 1 red of 112, the package-less pin. The loader's loadMany answered the body carrying the host's _packageId stamp. Restored to the HEAD blob with an empty git diff HEAD.
  • Changed lines: 49 files, +2763 / −167 = 2930 against origin/main at 14754c70 (under 3000).

  • feat(objectql)!: email template names held per namespace, and ?package= reads an email template exactly (ADR-0150 child B, webhook family 1/3) #32089 is closed as superseded.

Verification

  • pnpm --filter @objectstack/plugin-email exec vitest run src/template-loader.test.ts src/send-template.test.ts
  • pnpm --filter @objectstack/objectql exec vitest run src/package-scoped-exact-qualifier.test.ts (needs metadata-protocol built)
  • pnpm --filter @objectstack/service-automation exec vitest run src/builtin/notify-node.test.ts
  • pnpm --filter @objectstack/dogfood exec vitest run test/bare-name-one-holder-flow-family.dogfood.test.ts (over a built dist)

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzgX7CHbaN1f2ZtiAiS

…, not per deployment (ADR-0150 child B, part 1 of the webhook family)

WIP: the switch, the platform-requested auth.* names, the bundle-addition
rule, the package-scoped /meta read and the send's holder-filtered bundle.

Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v
Co-authored-by: Claude <noreply@anthropic.com>
…latform-requested auth names and the bundle-addition rule

Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v
Co-authored-by: Claude <noreply@anthropic.com>
…nd a pair's member as the package's own

A read naming a dependent whose only member of a bundled name is an addition
(a locale its base does not ship) was refused not-held-by-qualifier once
email_template joined the package-scoped readers, and a pair's added member
on its base's address was replaced by the registry's item. The qualifier now
asks the holders as the caller it names, and keeps an item of a pair of the
admitted package.

The co-owner dogfood case asserts the ADR-0146 D2 refusal a co-owner meets
first at install-local.

Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v
Co-authored-by: Claude <noreply@anthropic.com>
…he email template's narrowing; changeset

Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v
Co-authored-by: Claude <noreply@anthropic.com>
…s base's address for a bundled type only

Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v
Co-authored-by: Claude <noreply@anthropic.com>
…rowing; pin the qualifier's holder ask for the nine

Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v
Co-authored-by: Claude <noreply@anthropic.com>
…mplate send resolves its name from the caller's package (ADR-0150 D3)

The flow notify node records the package whose own flow body is armed
beside its template reference; the email and inbox channels hand it to
sendTemplate/renderTemplate as callerPackageId; the registry loader
resolves the name from it and, for a name two packages hold, reads the
resolved holder's own address. A send that names no package keeps the
bare-name meaning.

Claude-Session: https://claude.ai/code/session_01WGufzgX7CHbaN1f2ZtiAiS
Co-authored-by: Claude <noreply@anthropic.com>
…is refused on both exact reads and listed by the upgrade report

Two packages of one App share the App's customization pair, so a locale
row there with no overrides declaration cannot say whose template it
extends: each holder's exact read refuses it naming every holder, the
list scoped to a holder leaves it out, and os migrate stored-references
lists it under pairMembers. One holder, the App's own address and a row
that declares its base keep being served. Pins for F1 (loader rungs,
service ladder, notify node, channels, dogfood) and F2; changeset.

Claude-Session: https://claude.ai/code/session_01WGufzgX7CHbaN1f2ZtiAiS
Co-authored-by: Claude <noreply@anthropic.com>
…otype.at, which the package's lib target does not declare

Claude-Session: https://claude.ai/code/session_01WGufzgX7CHbaN1f2ZtiAiS
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 10, 2026
@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 9 package(s): @objectstack/cli, @objectstack/core, @objectstack/metadata-protocol, @objectstack/metadata, @objectstack/objectql, @objectstack/plugin-email, @objectstack/service-automation, @objectstack/service-messaging, @objectstack/spec, touching 99 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts, packages/metadata/src/index.ts, packages/plugins/plugin-email/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

37 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json c6230f4eeb0d099361dffba340f855a280a3334f.

⛔ 16 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts, packages/metadata/src/index.ts, packages/plugins/plugin-email/src/index.ts) — pages documenting those are invisible to this run
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 191 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c6230f4eeb0d099361dffba340f855a280a3334f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4b8ed3604208abe8508f54a2a50516c728d5e079 — the merge of head 27abd458a8f948315e70a1636d2a83055d6bf3ea into base c6230f4eeb0d099361dffba340f855a280a3334f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4b8ed3604208abe8508f54a2a50516c728d5e079 && git checkout 4b8ed3604208abe8508f54a2a50516c728d5e079
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c6230f4eeb0d099361dffba340f855a280a3334f 27abd458a8f948315e70a1636d2a83055d6bf3ea && git checkout -B drift-repro c6230f4eeb0d099361dffba340f855a280a3334f && git merge --no-ff 27abd458a8f948315e70a1636d2a83055d6bf3ea

node scripts/docs-audit/affected-docs.mjs --json c6230f4eeb0d099361dffba340f855a280a3334f

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c6230f4eeb0d099361dffba340f855a280a3334f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…) for email_template, beside api

Two packages of two namespaces may now each hold one email template name, so
the second package's item no longer overwrites the first's in MetadataManager's
in-memory store: a read naming a package answers that package's own, and a read
naming none of a two-holder name is the D3 refusal (#31527 B-shape item 1).
Pins that email_template has no metadata redactor, so a save never takes the
carry-forward's code-layer read for it (item 4).

Claude-Session: https://claude.ai/code/session_01WGufzgX7CHbaN1f2ZtiAiS
Co-authored-by: Claude <noreply@anthropic.com>
…-3giljf

# Conflicts:
#	packages/objectql/src/package-scoped-e8-readers.test.ts
#	packages/objectql/src/package-scoped-read.ts
…-3giljf

# Conflicts:
#	packages/cli/src/utils/stored-package-references.ts
#	packages/metadata/src/package-slotted-store.ts
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#31527#1 · os-support-ai · session_01WGufzj7TEH65FDimZdn1H3

Epic red-line review of #32214 (#31699 PR 1/3, email_template) at head 4c176c5: no PASS yet. One item is owed before landing. I read the diff and body, but did not run them. Contract review and ACCEPT are seat 1's. Everything else holds against the three red lines.

Owed: a legacy package-less row read under two holders. Ruling 6761802819 says it "must be refused for every covered type before that type narrows". This PR narrows email_template, and the row is still served at both addresses.

  • Where. In exactQualifierItem, a package-less row has no string _packageId, so the function returns it before any holder check. Both ?package=P1 and ?package=P2 then serve one environment row as each package's own.
  • Why this PR makes it reachable. The widening lets a second namespace install a name P1 held alone. A package-less row written while P1 held the name was P1's customization (D4). Once P2 installs, P2's exact address serves P1's customization, which red line 1 forbids. This is reachable through install, not only through a clash sealed before Phase 1.
  • Do (small, and generic in exactQualifierItem, so it covers every type in PACKAGE_SCOPED_READ_TYPES):
    1. When the read names a package, and the item has no package while two or more packages hold the name, refuse with 409 ambiguous-in-closure naming every holder.
      • The _env address keeps serving the environment's own row.
      • With one holder, the row is that holder's customization, as today.
      • Apply the same rule to every read that layers the package-less row as a holder's overlay, including the send's registry loader for a package caller.
    2. os migrate stored-references lists such rows beside pairMembers, with their candidates, so the operator saves the row under one package.
    3. Pins: two holders refuse at both exact addresses; one holder serves; _env serves; an ablation.
  • Declare. The narrowing covers agent and skill, which already narrowed on main with this gap, and the nine E8 types. Give FROM→TO and a first-party census.
  • Size. If this pushes the PR past 3,000 lines, carry it as a precondition card that lands first. feat(objectql)!: email template names held per namespace, a send resolves its template from its package, and ?package= reads an email template exactly (ADR-0150 child B, webhook family 1/3) #32214 does not land with the path open.
  • Not owed here: binding the row to the incumbent when a second holder arrives, as flows do after the seal. Refusing is fail-closed. A bind is a later card, if an operator asks for it.

Holds:


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-support-ai · session_01WGufzg5AUtn9pwa3g2bBqk

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4c176c5b88efb08d6267b677c44678440b48daf6
Local-runs: none
Scope: packages/spec/src/contracts/email-service.ts + packages/plugins/plugin-email/src/email-service.ts, packages/plugins/plugin-email/src/index.ts, packages/plugins/plugin-email/src/template-loader.ts, packages/plugins/plugin-email/src/template-loader.test.ts, packages/plugins/plugin-email/src/send-template.test.ts, packages/services/service-automation/src/builtin/notify-node.ts, packages/services/service-automation/src/builtin/notify-node.test.ts, packages/services/service-automation/src/engine.ts, packages/services/service-messaging/src/email-channel.ts, packages/services/service-messaging/src/email-channel.test.ts, packages/services/service-messaging/src/inbox-channel.ts, packages/services/service-messaging/src/inbox-channel.test.ts, packages/core/src/package-scoped-resolution.ts, packages/core/src/package-scoped-resolution.test.ts, packages/metadata/src/package-slotted-store.ts, packages/metadata/src/metadata-manager-email-template-slot.test.ts, packages/metadata-protocol/src/index.ts, packages/metadata-protocol/src/protocol-exact-qualifier.ts, packages/metadata-protocol/src/protocol-exact-qualifier.test.ts, packages/metadata-protocol/src/protocol-meta-list-reads.ts, packages/metadata-protocol/src/protocol.metadata-redaction.test.ts, packages/objectql/src/bare-name-one-holder.ts, packages/objectql/src/package-scoped-read.ts, packages/objectql/src/registry.ts, packages/objectql/src/package-scoped-e8-readers.test.ts, packages/objectql/src/package-scoped-exact-qualifier.test.ts, packages/objectql/src/package-scoped-resolver.test.ts, packages/objectql/src/registry-bare-name-one-holder-ai-family.test.ts, packages/objectql/src/registry-bare-name-one-holder-flow-family.test.ts, packages/cli/src/utils/stored-package-references.ts, packages/cli/src/utils/stored-package-references.literal-namespace.test.ts, packages/cli/src/commands/migrate/stored-references/index.ts, packages/cli/src/commands/migrate/stored-references.integration.test.ts, packages/qa/dogfood/test/bare-name-one-holder-flow-family.dogfood.test.ts

Faces: schema (the published contract file is the one hits path) and narrowing (Clause-②: yes (narrowing)), so every path of the diff outside .changeset/ is in scope. Read, never reviewed: packages/core/src/package-scoped-resolution.ts (the rule), packages/objectql/src/package-scoped-resolver.ts, packages/objectql/src/registry.ts outside its hunks, packages/metadata-protocol/src/protocol-meta-list-reads.ts outside its hunk, packages/services/service-automation/src/run-resume.ts, packages/spec/src/automation/io-node-config.zod.ts, packages/spec/src/system/email-template.zod.ts, packages/spec/src/kernel/override-declaration.zod.ts, scripts/pm/clause2-line.mjs.

Inputs: card #31699 (body and its 8 comments), PR #32089 (its record 6761777785 and the epic rulings 6761802819), PR #32214 (body, 36 files, net diff against main, merge base bb67b98), the check-runs on the head, and the epic #31527 rulings and B-shape additions 6766935846. Everything below was read in the tree. Nothing was built or run.

Gate verdicts on this head: none exist. The check-runs endpoint answers 0 runs for 4c176c5b, the combined status is pending with 0 statuses, and no workflow run names the head. The PR reads mergeable: false, mergeable_state: dirty: main moved to bb47215 and #32117 edited packages/objectql/src/package-scoped-read.ts and packages/objectql/src/package-scoped-e8-readers.test.ts, the two files both sides change. The last head CI ran on was 1484ab5 (67 runs: 54 success, 11 skipped, 2 failure, both Test UI), which is before the slotted-store commit and both merges. This record does not stand in for the gates, and the dev's local numbers are not evidence here.

① Derived judgments

  1. SendTemplateInput.callerPackageId and RenderTemplateInput.callerPackageId, optional string | null (packages/spec/src/contracts/email-service.ts:295, :333). Additive. A caller that omits it and an implementer that ignores it both keep today's behaviour. RIGHT as an addition. Its jsdoc sentence about the notify node is F-A in ④.
  2. EmailService.resolveAndRenderTemplate hands the caller to bundleLocales and to all four loader.load rungs; '', null and omitted all send no options. TemplateLoader.load gains an optional third parameter and TemplateLoadOptions is exported. The template is resolved inside sendTemplate, before anything is queued, so no later rung loses the caller. RIGHT.
  3. bundleAddressOf (template-loader.ts): the name resolves by the one rule from the caller; a refusal throws; a name with fewer than two non-baseline holders reads the environment-wide list as before; with two or more, the bundle is read at the resolved holder's address (getMetaItemsForExecution with packageId, or the registry list filtered by _packageId). Read against the rule: a caller with no package and two holders gets ambiguous-in-environment; a package caller gets its own, else the one holder in its closure, else outside-closure naming every holder and the environment item. The registry's environment item exists only under a name no package holds, so the environment rung cannot mix two holders' members. pickBundleMember answers some member whenever the holder has one, so no two-holder name falls through to the environment-wide list. A caller holding only an addition is resolved again without its own holding and reads its base's address. RIGHT.
  4. notify node: templateCallerPackageId is engine.flowCallerPackage(context) when cfg.template is set, and a literal payload.templateCallerPackageId is dropped. flowCallerPackage reads the run's own flow name (resolveRunContext stamps flow.name, so a subflow run names itself) and armedFlowPackage, which answers only for a body armed from the package. RIGHT for the template: key. Not right for a template reference written in the literal payload: F-A.
  5. AutomationEngine.flowCallerPackage goes from private to public. Read-only, no state. RIGHT.
  6. Email and inbox channels forward payload.templateCallerPackageId as callerPackageId on the send, the render and the currency-probe renders. RIGHT.
  7. NAMESPACE_SCOPED_BARE_NAME_TYPES is agent, skill, email_template. PACKAGE_SLOTTED_STORE_TYPES is api, agent, skill, email_template, in the same change, and the ai-family superset pin iterates the live set. RIGHT on this tree. PACKAGE_SCOPED_READ_TYPES keeps every entry of the merge base and adds email_template before picklist; it does not carry mapping, which main added after the merge base (③).
  8. bareNameHoldersMeet takes name; a platform-requested name keeps one holder. PLATFORM_REQUESTED_BARE_NAMES_BY_TYPE.email_template lists the eight auth.* names. Census by reading at this head: sendTemplate and renderTemplate are called only from plugin-auth and the two messaging channels. RIGHT, as Q2 = A.
  9. Bundle addition (declaredBundleMembers, isBundleAdditionPair, the three registry call sites): an addition meets its base whatever the namespaces, at the door, the seam and the seal. RIGHT, as Q4 = A.
  10. exactQualifierItem, pairMemberOwners, pairMemberWithheldAt and the scoped list's use of the last: judged in ④ (F2).
  11. collectStoredPackageReferences adds pairMembers; the command prints and counts them. RIGHT as a report; its remedy is F-C.

② Semver level

Changeset .changeset/31699-email-template-names-per-namespace.md: nine packages, all minor; a ! subject; a BREAKING paragraph with a one-line fix for each of the three callers; one ADR-0087 marker (not-required (no-migration-prescription)); both declaration lines present and equal to the PR body's.

  • Metadata-protocol: added takes at least minor on every package the diff moves (scripts/pm/clause2-line.mjs, ruling A). All nine are minor, @objectstack/spec included. The level holds.
  • The spec change alone (two optional members on an in-process interface) is ruling A's none; added rests on the accept set (a name a second namespace's package may now declare). Narrowing (3) refuses, at delivery, a template: value that was served before. If the seat reads that as "a value refused", narrowed outranks added. The level is minor either way. Nit N1.
  • Clause-②: yes (narrowing) is the right arm, and the line now names all three refusals. The ADR-0087 disposition is right: no key, value or stored row changes shape.
  • Check Changeset, check:generated and check:api-surface have no verdict on this head (see the gate paragraph above).

③ Boundary flags

  • Dev report 6767484650: status: done, open_questions: [], security_findings: none, out_of_scope_findings: []. It reports head 1484ab5, not the reviewed head, and says CI was pending.
  • No gate verdict and no mergeable state on the reviewed head. BLOCKING for landing, whatever ④ says: main must be merged again (keeping mapping and email_template in PACKAGE_SCOPED_READ_TYPES and both in the e8 test's WIRED_SINCE), and the resulting head needs its own check-runs and its own record.
  • Dogfood battery: the full battery ran on 1484ab5, before the slotted-store commit. On the reviewed head the PR body reports 9 files only. The slotted-store entry for email_template therefore has neither a full battery nor CI on it. ESCALATED to the seat: owed on the final head (the title carries ! and the arm is (narrowing)).
  • Size: 36 files, +1674 / -112, about 1786 changed lines against the 3000 threshold. Within it.
  • The narrowing beyond the card's declared one (the card body says Clause-②: no, narrowing for co-owners only). ANSWERED from the inputs: (3) and the environment-only holder are the epic reading 6728487836 ("inside ADR-0150 as ruled", each PR declares it with its measured count); (1) is ADR-0150 D5 and (2) its covered-types paragraph. First-party counts are stated as 0 for each. The artifact-sibling sub-case that reading asks every PR to count is not stated by name; with no packaged flow sending a template it is 0 by the same census. The epic red-line read of this PR does not exist yet (ledger 6770519998: "Not yet read") and is owed before landing.
  • B-shape item 1 (slotted store before narrowing): met on this tree, same change, pinned in metadata-manager-email-template-slot.test.ts and by the live-set superset pin. The pin holds one locale per package; a bundle with two locales under one (package, name) slot is not pinned (the store kept one item per name before, so this is no change).
  • B-shape item 2 (removal by slot): not built, rides metadata: unregisterPackage and unregisterAt remove a slotted type's item by (package, name), match an artifact-door item's _packageId, and name the package on the deleted event (ADR-0150 D4) #32198, which is open and dispatched. metadata: the artifact door's memory loader and the database loader keep one row per name, so a slotted type's removed or second holder's item is still served (ADR-0150 D4) #32246 (loader residue below the slotted store) is open too. Both cards put first-party reach at 0. ACCEPTED as declared, and both stay preconditions the epic ledger tracks.
  • B-shape item 4: email_template registers no redactor, pinned in protocol.metadata-redaction.test.ts. ANSWERED.
  • B-shape item 5 (a disabled package still counted at the doors): declared as objectql + metadata-protocol + runtime: by-name doors judge a name's holders over the served list, so a disabled package neither refuses nor is named (ADR-0150 D3; #32101 F4 class) #32183's class in the changeset and the PR body. ACCEPTED, for three reasons read in the tree. objectql + metadata-protocol + runtime: by-name doors judge a name's holders over the served list, so a disabled package neither refuses nor is named (ADR-0150 D3; #32101 F4 class) #32183 itself names the email template pre-check as coming "under this card's rule when its type narrows" and fixes the holder set once at the source, which the send's new door reads (packageScopedHoldersOf). Every effect here is a refusal that names the disabled holder, or a miss; no path serves a disabled package's template or picks between two. And the install gate must keep counting it. ESCALATED to the epic seat for its red-line read only because item 5's sentence is written as a property of the door and this PR narrows with it open.
  • Legacy package-less row under two holders: declared, not changed. The epic ruling 6761802819 says this PR declares it, and also that it "must be refused for every covered type before that type narrows"; this PR narrows email_template. F-D in ④. ESCALATED to the epic seat: one line saying the narrowing may land with it open.

④ Design judgment

Earlier findings (record 6761777785 on #32089), judged on this tree.

  • F1 (must-fix): fixed for the template: key. The send carries the caller, the loader resolves by D3 from it, a two-holder name is read at the resolved holder's address, and a send with no package keeps environment item, then the one holder, then 409. Pinned at each rung. One spelling of the same reference is left on the old rule: F-A.
  • F2, sibling pairs (should-fix): fixed. Two holders sharing the pair get 409 on both exact reads naming both, both scoped lists leave the row out, one holder is served, and a row declaring its base is served at that base. Pinned. One state is left: F-B.
  • F2, legacy package-less row: not fixed, declared, as the epic ruled. F-D.
  • N1: fixed. The Clause-②: line names the bare 409 for a clash sealed before phase 1.
  • N2: fixed. The sentence is now true in code for a two-holder name.
  • N3: the report now states a battery, on 1484ab5. See ③ for the reviewed head.

packages/spec/src/contracts/email-service.ts (hits), against ADR-0150 D3 and its first consumers. The member's contract is D3 as ruled: own, then the one holder in the declared closure, then a refusal naming every candidate; omitted or null keeps the bare-name meaning; the platform's auth mails resolve environment-wide. The first consumers agree with it: EmailService forwards it on every rung, the registry loader implements each step, and the two channels mirror it structurally with a compile-checked key. The one sentence that does not hold at its consumer is "A flow's notify node passes the package whose own flow body is armed" (F-A).

F-A — must-fix. A packaged flow still resolves a template name environment-wide when the reference is written in the node's literal payload.

  • notify-node.ts:1010 computes the caller only when cfg.template is set. notify-node.ts:1178-1186 spreads the literal payload first and removes one key from it, templateCallerPackageId (:740-744). A literal payload: { template: 'NAME' } on a node with inline title / message survives into the emitted payload beside title and body.
  • Both channels take the template path on that key alone (email-channel.ts:485-488, inbox-channel.ts:178-181) and ignore the inline strings. No caller was recorded, so sendTemplate / renderTemplate run with no package (email-channel.ts:524-527).
  • The spec's payload is z.record(z.string(), z.unknown()) and its refinement reads cfg.template, not payload.template, so nothing refuses the spelling at authoring.
  • Result, read not run: from a package whose own body is armed, a template held only by a package outside its declared closure, or authored only in the environment, is served. That is the read narrowing (3) declares refused, and red line 1's "no environment-wide guess for a caller with a package". For a two-holder name the same spelling is refused on every send, the holder's own flow included, which is F1's failure for this spelling.
  • The diff states the intent it does not meet: "an authored key can never resolve the template from another package" (notify-node.ts:735-738), and the contract file says the node passes the armed package.
  • Not a regression (main resolves every send this way), and first-party use is 0 by the PR's own census of template: keys, which did not count this spelling. It is must-fix because ADR-0150 D9 narrows a type only after its doors carry the package, the epic ruling on F1 is that the notify node passes the armed package, and this is that node.
  • Fix, either: record the caller whenever the emitted payload carries a template reference, whichever key wrote it; or drop template and templateData from the literal payload as the diff already drops templateCallerPackageId. Pin the literal spelling beside the existing "literal key dropped" case. If the epic seat instead rules the literal spelling outside the node's contract, the node must refuse or drop it, and the changeset says so.

F-B — should-fix. A bound pair row is still served at the App's own address.

  • protocol-exact-qualifier.ts:185 returns the item for the App's own pair before the declared base is read (:191), and pairMemberWithheldAt does the same at :267 before :269.
  • So when the App itself holds the name beside its member X, a row declaring overrides.package: X is served and listed at the App's address as well as X's.
  • The function's own doc says such a row "is served on that base's address only", and the epic's pin is "after a bind, only the bound pair is served". The bind test holds the name in X and Y only, so the state is unpinned.
  • Fix: read the declared base first in both functions, or pin the state with the answer the epic seat gives.

F-C — should-fix. The report's remedy for an ambiguous pair row has no door.

  • The epic ruling asks that os migrate stored-references list the row "so the operator can bind it". The command prints "Declare in the row the package it customizes (overrides.package)" (stored-references/index.ts:243) and the module says the row is "Not bindable by bind" (stored-package-references.ts:51).
  • Nothing in the diff writes that declaration, and overrides is ADR-0146 D2's declaration that an item replaces its base's same-named item whole (override-declaration.zod.ts). Here it is read as the binding of an added member, which replaces nothing. Whether the save door admits it on a member the base does not ship was not readable from the diff.
  • Fix: name the door that writes it (or let bind write it), and pin that a row so written passes the save gate.

F-D — should-fix. The legacy package-less row now reaches the send, and the changeset contradicts its own change.

  • With two holders, the list scoped to either holder seats the package-less row (standInRows), so each holder's send renders it. One holder's customization is sent as the other's. The declaration names only "both exact addresses serve it".
  • .changeset/...:34 says the row is "to be refused for every covered type before that type narrows", in the change that narrows email_template without refusing it.
  • Fix: say in the changeset and the PR body that the sends of both holders render the row too, and replace the sentence with what is true here (this type narrows with the item open, under the epic seat's line asked for in ③). The report does not list such a row either; say so or list it.

Implementing files, the rest.

  • template-loader.ts: agrees with the contract (① 3). Its doc sentence on a dependent's addition matches the code and the scoped list.
  • email-service.ts, index.ts: agree (① 2).
  • engine.ts: agrees (① 5).
  • email-channel.ts, inbox-channel.ts: agree (① 6). templateCallerPackageOf trusts whatever producer wrote the key, which is the stated design: the node is the only in-tree writer.
  • package-scoped-resolution.ts: the list and its doc agree with Q2 = A.
  • bare-name-one-holder.ts, registry.ts: agree with D2, D9 and Q4 = A; the co-owner, the namespace-less package, the shareable namespaces and built-ins keep the refusal through bareNameHoldersMeet and bareNameHoldersOtherThan.
  • package-scoped-read.ts: agrees on this tree; conflicts with main (③).
  • package-slotted-store.ts: the set and its doc agree with B-shape item 1.
  • protocol-exact-qualifier.ts, protocol-meta-list-reads.ts: agree with the F2 ruling except F-B.
  • stored-package-references.ts, stored-references/index.ts: read-only, keyset walk, stable order, counted in the total; F-C.
  • Tests: the removed lines in the flow-family, ai-family, resolver and CLI integration tests are each replaced by the narrowed assertion or the widened set; no pin of another type is weakened. The F1 pins cover each rung and each control. Missing pins: the literal payload spelling (F-A), the App-held bound row (F-B).

Nits (prose faces, no effect on the contract).

  • N1: Metadata-protocol: added against narrowed (②).
  • N2: .changeset/...:28 says the slotted set is "now api and email_template"; the tree has api, agent, skill, email_template.
  • N3: content/docs/kernel/runtime-services/email-service.mdx documents SendTemplateInput and does not carry the new member; the PR's Skills-docs: none line does not mention the page.

Implemented-by: session_01WGufzgX7CHbaN1f2ZtiAiS
Reviewed-by: session_01WGufzg5AUtn9pwa3g2bBqk

VERDICT: FAIL


Generated by Claude Code

…es hold is no holder's own

A package-less row (the environment's item, or a sys_metadata row bound to
no package) of a name two or more packages hold is the customization of one
holder and cannot say which (ADR-0150 D4). Each holder's exact read now
refuses it 409 ambiguous-in-closure naming every holder, the list scoped to a
holder (which a send made from it reads) leaves it out, and
os migrate stored-references lists it under packageLessRows. One holder,
the _env address and the platform-requested auth mails are unchanged. The
rule sits in the exact qualifier, so it covers every type read by package
(epic ruling 6761802819 on #31699).

Claude-Session: https://claude.ai/code/session_01WGufzgX7CHbaN1f2ZtiAiS
Co-authored-by: Claude <noreply@anthropic.com>
…-3giljf

# Conflicts:
#	packages/objectql/src/package-scoped-e8-readers.test.ts
#	packages/objectql/src/package-scoped-read.ts
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#31527#1 · os-bill · session_01Utp6CYGYEUhpi29FpjLmxT

Contract review — delta (merge of main into reviewed head)

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 27abd458a8f948315e70a1636d2a83055d6bf3ea
Local-runs: probe — the merged head's build and the targeted suites of the co-touched packages, because a merge commit's semantic conflicts cannot be read from either parent's diff
Scope: merge commit 27abd45 over prior PASS 6811135944 at 17cfbf8; main 14754c7..c6230f4 intersected with the PR surface

① Derived judgments

② Semver level

Unchanged from 6811135944. The merge adds no PR-authored change; .changeset/31699-email-template-names-per-namespace.md is intact and is the only changeset in the PR diff.

③ Boundary flags

None new. There is no duplicate or removed export in @objectstack/metadata-protocol or @objectstack/objectql, and no signature the PR calls was changed by main. execctx-consumer-census and route-ledger.conformance are green.

④ Design judgment

Additive on both sides. The ADR-0150 claims the prior PASS relied on are neither weakened nor contradicted by main.

Implemented-by: claude/project-thread-3giljf
Reviewed-by: session_01Utp6CYGYEUhpi29FpjLmxT

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#31527#1 · os-bill · session_01Utp6CYGYEUhpi29FpjLmxT

Landing — seat record re-pinned to the merge head

Reading Value
Head 27abd458a8f948315e70a1636d2a83055d6bf3ea (merge of origin/main c6230f4 into 17cfbf8 via relay pr_update_branch; the PR's own diff is patch-id identical)
Contract review PASS 6811135944 at 17cfbf8 + delta PASS 6812573826 at 27abd45
Seat record 6811168620 (速读 + 席位核对) stands; its checks carry to this head unchanged
Check-runs at head 58 success, 10 skipped, 0 other
mergeable_state clean
K (first-parent main commits past merge-base) 4
Governed surface not governed (check-governed-merges.mjs --pr 32214, 0 of 49 paths)
Size 2930 changed lines (+2763 / −167), ≤ 3000
merge=os-regen paths in diff none

State change: ready for review + auto-merge (merge queue) in this stroke. On MERGED: #32422 closes via Fixes; #31699 stays open for PR 2 and PR 3.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 12, 2026 02:44
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit f56354c Nov 12, 2026
69 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-3giljf branch November 12, 2026 03:12
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#31527#1 · os-bill · session_01Utp6CYGYEUhpi29FpjLmxT

Landed

Probe Reading
PR state merged 2026-11-12T03:12:44Z via the merge queue, merge commit f56354c2cd5d69f5da95fc9e56046b25f3b1ff41, an ancestor of origin/main
Issues closed at merge #32422 only (via Fixes), as expected; #31699 stays open for PR 2 (webhook) and PR 3 (connector)

Status: pm:dispatched and pm:blocking removed from #32422. #31699 keeps its claim for PR 2 and PR 3, each in a new PR. The blocked #32214 follow-ups (#32407, #32408, #32458, #32488, #32489) are already claimed and dispatched by epic seat 2 and server seat 5.

This was referenced Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant